Fix phpstan/phpstan#13526: False positive with array_key_exists and union types#5103
Fix phpstan/phpstan#13526: False positive with array_key_exists and union types#5103phpstan-bot wants to merge 135 commits intophpstan:2.1.xfrom
Conversation
Automatically detects undocumented parameters in conf/parametersSchema.neon and creates a draft PR on phpstan/phpstan with documentation updates. Triggers on push to 2.2.x when the schema changes, or manually. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use github.event.before for push diffs to handle multi-commit pushes. Add explicit else branch for manual dispatch to check entire schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These parameters exist purely to be toggled by rule levels and are not configured by users directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The safe-output patch was trying to create the file as new, but it already exists in phpstan/phpstan. Add a pre-step that fetches and commits the file so the agent's edits produce a modification patch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of using safe-outputs create-pull-request (which generates patches that fail on cross-repo applies), checkout phpstan/phpstan to __phpstan-website/ subdirectory, edit config-reference.md in place, and push branch + create PR via bash/gh CLI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The gh-aw framework runs git commands in the workspace root expecting a git repository. Without checking out phpstan-src first, the "Configure Git credentials" step fails with "not a git repository". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The parametersSchema.neon has nested structure() blocks like exceptions.check.* and cache.*. The agent was only looking at top-level parameters and missing nested ones like throwTypeCovariance and tooWideImplicitThrowType. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
6f5f9cb to
76da17b
Compare
| - name: "Checkout" | ||
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | ||
| with: | ||
| ref: 2.1.x |
Check failure
Code scanning / octoscan
Use of 'actions/checkout' with a custom ref. Error
| ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" \ | ||
| --repo phpstan/phpstan \ | ||
| --json title,body,url) | ||
|
|
Check failure
Code scanning / octoscan
Write to "$GITHUB_OUTPUT" in a bash script. Error
| --repo phpstan/phpstan \ | ||
| --json title,body,url) | ||
|
|
||
| TITLE=$(echo "$ISSUE_JSON" | jq -r '.title') |
Check failure
Code scanning / octoscan
Write to "$GITHUB_OUTPUT" in a bash script. Error
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | ||
| GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }} | ||
| run: | | ||
| git config user.name "phpstan-bot" |
Check failure
Code scanning / octoscan
Expression injection, "steps..outputs." is potentially untrusted. Error
| PROMPT_EOF | ||
| )" | ||
|
|
||
| - name: "Read Claude's summary" |
Check failure
Code scanning / octoscan
Write to "$GITHUB_OUTPUT" in a bash script. Error
| cat /tmp/commit-message.txt | ||
| echo "${delimiter}" | ||
| } >> "$GITHUB_OUTPUT" | ||
| else |
Check failure
Code scanning / octoscan
Write to "$GITHUB_OUTPUT" in a bash script. Error
| echo "${delimiter}" | ||
| } >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "commit_message=Fix #$ISSUE_NUMBER" >> "$GITHUB_OUTPUT" |
Check failure
Code scanning / octoscan
Write to "$GITHUB_OUTPUT" in a bash script. Error
| echo "### Selected issue: #$NUMBER - $TITLE" >> "$GITHUB_STEP_SUMMARY" | ||
| done | ||
| echo "matrix=$(echo "$SELECTED" | jq -c '.')" >> "$GITHUB_OUTPUT" |
Check failure
Code scanning / octoscan
Write to "$GITHUB_OUTPUT" in a bash script. Error
| fail-fast: false | ||
| matrix: | ||
| issue: ${{ fromJson(needs.pick-issues.outputs.matrix) }} | ||
| uses: ./.github/workflows/claude-fix-issue.yml |
Check failure
Code scanning / octoscan
Use of local workflow "./.github/workflows/claude-fix-issue.yml" Error
| path: phpstan-dist | ||
| token: ${{ secrets.PHPSTAN_BOT_TOKEN }} | ||
| ref: 2.1.x | ||
| ref: 2.2.x |
Check failure
Code scanning / octoscan
Use of 'actions/checkout' with a custom ref. Error
| You are working on phpstan/phpstan-src, the source code of PHPStan - a PHP static analysis tool. | ||
|
|
||
| Your task is to fix the following GitHub issue from the phpstan/phpstan repository: | ||
| Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }} |
Check failure
Code scanning / zizmor
code injection via template expansion Error
| ## Test | ||
| Describe the regression test that was added. | ||
|
|
||
| Fixes phpstan/phpstan#${{ inputs.issue-number }} |
Check failure
Code scanning / zizmor
code injection via template expansion Error
| fail-fast: false | ||
| matrix: | ||
| issue: ${{ fromJson(needs.pick-issues.outputs.matrix) }} | ||
| uses: ./.github/workflows/claude-fix-issue.yml |
Check warning
Code scanning / zizmor
secrets unconditionally inherited by called workflow Warning
|
|
||
| permissions: | ||
| contents: read | ||
| issues: read |
Check warning
Code scanning / zizmor
permissions without explanatory comments Warning
| - name: Trigger Claude Random Easy Fixes | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: gh workflow run claude-random-easy-fixes.yml -f issue_count=5 --repo ${{ github.repository }} |
Check warning
Code scanning / zizmor
code injection via template expansion Warning
| timeout-minutes: 60 | ||
| permissions: | ||
| contents: read | ||
| issues: read |
Check warning
Code scanning / zizmor
permissions without explanatory comments Warning
| name: "Claude Fix Issue" | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| issue-number: | ||
| description: "Issue number from phpstan/phpstan repository" | ||
| required: true | ||
| type: string | ||
| workflow_call: | ||
| inputs: | ||
| issue-number: | ||
| description: "Issue number from phpstan/phpstan repository" | ||
| required: true | ||
| type: string | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| fix: | ||
| name: "Fix #${{ inputs.issue-number }}" | ||
| runs-on: "ubuntu-latest" | ||
| timeout-minutes: 60 | ||
| permissions: | ||
| contents: read | ||
| issues: read | ||
| pull-requests: write | ||
|
|
||
| steps: | ||
| - name: Harden the runner (Audit all outbound calls) | ||
| uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 | ||
| with: | ||
| egress-policy: audit | ||
|
|
||
| - name: "Checkout" | ||
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | ||
| with: | ||
| ref: 2.1.x | ||
| repository: phpstan/phpstan-src | ||
| fetch-depth: 0 | ||
|
|
||
| - name: "Install PHP" | ||
| uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 | ||
| with: | ||
| coverage: "none" | ||
| php-version: "8.4" | ||
| ini-file: development | ||
| extensions: mbstring | ||
|
|
||
| - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 | ||
|
|
||
| - name: "Install Claude Code" | ||
| run: npm install -g @anthropic-ai/claude-code | ||
|
|
||
| - name: "Fetch issue details" | ||
| id: issue | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| ISSUE_NUMBER: ${{ inputs.issue-number }} | ||
| run: | | ||
| ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" \ | ||
| --repo phpstan/phpstan \ | ||
| --json title,body,url) | ||
|
|
||
| TITLE=$(echo "$ISSUE_JSON" | jq -r '.title') | ||
| URL=$(echo "$ISSUE_JSON" | jq -r '.url') | ||
| echo "title=$TITLE" >> "$GITHUB_OUTPUT" | ||
| echo "url=$URL" >> "$GITHUB_OUTPUT" | ||
| echo "$ISSUE_JSON" | jq -r '.body' > /tmp/issue-body.txt | ||
|
|
||
| - name: "Run Claude Code" | ||
| env: | ||
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | ||
| GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }} | ||
| run: | | ||
| git config user.name "phpstan-bot" | ||
| git config user.email "ondrej+phpstanbot@mirtes.cz" | ||
|
|
||
| claude --model claude-opus-4-6 \ | ||
| --dangerously-skip-permissions \ | ||
| -p "$(cat << 'PROMPT_EOF' | ||
| You are working on phpstan/phpstan-src, the source code of PHPStan - a PHP static analysis tool. | ||
|
|
||
| Your task is to fix the following GitHub issue from the phpstan/phpstan repository: | ||
| Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }} | ||
| URL: ${{ steps.issue.outputs.url }} | ||
|
|
||
| Issue body is in the file /tmp/issue-body.txt — read it before proceeding. | ||
|
|
||
| ## Step 1: Write a regression test | ||
|
|
||
| Read .claude/skills/regression-test/SKILL.md for detailed guidance on writing regression tests for PHPStan bugs. | ||
|
|
||
| The issue body is already provided above — start from Step 2 of the skill (deciding test type). For Step 1 (gathering context), you only need to fetch the playground samples from any playground links found in the issue body. | ||
|
|
||
| Skip Steps 5-6 of the skill (reverting fix and committing) — those are not needed here. | ||
|
|
||
| The regression test should fail without the fix — verify this by running it before implementing the fix. | ||
|
|
||
| ## Step 2: Fix the bug | ||
|
|
||
| Implement the fix in the source code under src/. Common areas to look: | ||
| - src/Analyser/NodeScopeResolver.php - AST traversal and scope management | ||
| - src/Analyser/MutatingScope.php - Type tracking | ||
| - src/Analyser/TypeSpecifier.php - Type narrowing from conditions | ||
| - src/Type/ - Type system implementations | ||
| - src/Rules/ - Rule implementations | ||
| - src/Reflection/ - Reflection layer | ||
|
|
||
| Read CLAUDE.md for important guidelines about the codebase architecture and common patterns. | ||
|
|
||
| ## Step 3: Verify the fix | ||
|
|
||
| 1. Run the regression test to confirm it passes now | ||
| 2. Run the full test suite: make tests | ||
| 3. Run PHPStan self-analysis: make phpstan | ||
| 4. Fix any failures that come up | ||
| 5. Run make cs-fix to fix any coding standard violations | ||
| 6. Run make name-collision and fix violations - add different tests in unique namespaces. If the function and class declarations are exactly the same, you can reuse them across files instead of duplicating them. | ||
|
|
||
| Do not create a branch, push, or create a PR - this will be handled automatically. | ||
|
|
||
| ## Step 4: Write a summary | ||
|
|
||
| After completing the fix, write two files: | ||
|
|
||
| 1. /tmp/commit-message.txt - A concise commit message (first line: short summary under 72 chars, then a blank line, then a few bullet points describing key changes). Example: | ||
| Fix array_key_exists narrowing for template types | ||
|
|
||
| - Added handling for TemplateType in TypeSpecifier when processing array_key_exists | ||
| - New regression test in tests/PHPStan/Analyser/nsrt/bug-12345.php | ||
| - The root cause was that TypeSpecifier did not unwrap template bounds before narrowing | ||
|
|
||
| 2. /tmp/pr-description.md - A pull request description in this format: | ||
| ## Summary | ||
| Brief description of what the issue was about and what the fix does. | ||
|
|
||
| ## Changes | ||
| - Bullet points of specific code changes made | ||
| - Reference file paths where changes were made | ||
|
|
||
| ## Root cause | ||
| Explain why the bug happened and how the fix addresses it. | ||
|
|
||
| ## Test | ||
| Describe the regression test that was added. | ||
|
|
||
| Fixes phpstan/phpstan#${{ inputs.issue-number }} | ||
|
|
||
| These files are critical - they will be used for the commit message and PR description. | ||
| PROMPT_EOF | ||
| )" | ||
|
|
||
| - name: "Read Claude's summary" | ||
| id: claude-summary | ||
| env: | ||
| ISSUE_NUMBER: ${{ inputs.issue-number }} | ||
| run: | | ||
| if [ -f /tmp/commit-message.txt ]; then | ||
| delimiter="EOF_$(openssl rand -hex 16)" | ||
| { | ||
| echo "commit_message<<${delimiter}" | ||
| cat /tmp/commit-message.txt | ||
| echo "${delimiter}" | ||
| } >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "commit_message=Fix #$ISSUE_NUMBER" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| if [ -f /tmp/pr-description.md ]; then | ||
| delimiter="EOF_$(openssl rand -hex 16)" | ||
| { | ||
| echo "pr_body<<${delimiter}" | ||
| cat /tmp/pr-description.md | ||
| echo "${delimiter}" | ||
| } >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "pr_body=Fixes phpstan/phpstan#$ISSUE_NUMBER" >> "$GITHUB_OUTPUT" | ||
| fi |
Check warning
Code scanning / zizmor
insufficient job-level concurrency limits Warning
|
|
||
| Your task is to fix the following GitHub issue from the phpstan/phpstan repository: | ||
| Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }} | ||
| URL: ${{ steps.issue.outputs.url }} |
Check notice
Code scanning / zizmor
code injection via template expansion Note
| contents: read | ||
|
|
||
| jobs: | ||
| trigger: |
Check notice
Code scanning / zizmor
workflow or action definition without a name Note
| You are working on phpstan/phpstan-src, the source code of PHPStan - a PHP static analysis tool. | ||
|
|
||
| Your task is to fix the following GitHub issue from the phpstan/phpstan repository: | ||
| Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }} |
Check notice
Code scanning / zizmor
code injection via template expansion Note
|
accidentally crashed the PR. creating a new one in #5114 |
Summary
Fixes a false positive where
array_key_exists($item, $map)with a union type array ($map = $a ? $map1 : $map2) still reported "Offset ... might not exist" when accessing$map[$item]inside the truthy branch.Changes
src/Analyser/MutatingScope.phpline 3683: Added&& $this->hasExpressionType($expr)->yes()to the performance optimization condition inaddTypeToExpression()tests/PHPStan/Rules/Arrays/data/bug-13526.phpand test methodtestBug13526inNonexistentOffsetInArrayDimFetchRuleTestRoot cause
When
array_key_exists($item, $map)is processed as a truthy condition, theArrayKeyExistsFunctionTypeSpecifyingExtensioncreates aSpecifiedTypesthat includes a sure type for$map[$item]with the correct value type (1|2).During
filterBySpecifiedTypes, this sure type is applied viaaddTypeToExpression(). However,addTypeToExpression()has a performance optimization that returns early (without callingspecifyExpressionType()) when the intersected type equals the already-computed type and is a constant scalar value. The problem is that forArrayDimFetchexpressions, the type is computed on-the-fly viagetTypeFromArrayDimFetch()without being stored inexpressionTypes. So the optimization returned early, and the expression type was never stored.Later,
NonexistentOffsetInArrayDimFetchRulechecks$scope->hasExpressionType($node)->yes()to determine if the dim fetch was explicitly narrowed (e.g., byarray_key_exists). Since the expression type was never stored, this returnedno, and the rule proceeded to report the false positive.The fix adds
$this->hasExpressionType($expr)->yes()to the optimization condition, ensuring the early return only happens when the expression type is already stored. This preserves the optimization for the common case (variables and already-stored expressions) while fixing the issue for computed-but-not-stored expressions likeArrayDimFetch.Test
Added
tests/PHPStan/Rules/Arrays/data/bug-13526.phpwith a reproduction case: two constant arrays selected via a ternary, followed byarray_key_exists+ access. The test expects no errors, confirming the false positive is fixed.Fixes phpstan/phpstan#13526